#!/usr/bin/perl
# Copyright (C) 2009-2013 Zentyal S.L.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License, version 2, as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA

# Script: backup-tool
#
# This script is called from crontab to carry out a full or incremental
# backup depending on the user configuration
#
# It takes one argument that can be:
#
#   --full (default)
#   --incremental
#   --full-only-once

use strict;
use warnings;

no warnings 'experimental::smartmatch';
use feature "switch";

use EBox;
use EBox::Config;
use EBox::Exceptions::External;
use EBox::Global;
use EBox::Gettext;
use EBox::Sudo;
use EBox::Event;

use TryCatch;

use constant EBACKUP_CONF => EBox::Config::etc() . 'ebackup.conf';

sub _sendEvent
{
    my ($msg, $level, $additionalInfo) = @_;

    EBox::info("EBackup event [$level]: $msg");
}

sub _prepareAndSendEvent
{
    my ($success, $type, $msgError) = @_;

    my ($msg, $level, $additionalInfo);

    my $printableMode;
    if ($type eq 'full') {
        $printableMode = __('full');
    } elsif ($type eq 'incremental') {
        $printableMode = __('incremental');
    } else {
        $printableMode = $type;
    }


    if ($success) {
        $msg = __x('Zentyal {mode} backup succeeded',
                    mode => $printableMode);
        $level = 'info';
        $additionalInfo = { success => 1, type => $type };
    } else {
        $additionalInfo = { error => 1, type => $type };
        if ($msgError) {
            $msg = __x("Zentyal {mode} backup failed: {error}",
                       mode  => $printableMode,
                       error => $msgError,
                      );
            $additionalInfo->{error_msg} = $msgError;
        } else {
            $msg = __x("Zentyal {mode} backup failed",
                       mode  => $printableMode,
                      );
        }
        $level = 'error';
    }
    _sendEvent( $msg, $level, $additionalInfo);
}

sub _mangleDuplicityErrorMsg
{
    my ($msg) = @_;

    my $spaceLeftRe = qr/^Temp space has (\d+) available, backup needs approx (\d+)/;
    my $backendException = qr/^BackendException:/;

    given ( $msg ) {
        when (m/$spaceLeftRe/) {
            my ($avail, $need) = ($1, $2);
            $avail = _bytesToMB($avail);
            $need = _bytesToMB($need);
            my $tmpDir = EBox::EBackup::tempdir();

            return __x(
                q/Temp directory '{dir}' has {avail} MB available, backup needs approx {need} MB. Either free some space or change temporal directory and volume size settings in {conf}./,
                dir   => $tmpDir,
                avail => $avail,
                need  => $need,
                conf  => EBACKUP_CONF,
               );
        }

        when ( m/$backendException/ ) {
            return 'BackendException';
        }

        default { return $msg; }
    }
}

# Parse the output of duplicity statistics after performing a successful backup
sub _parseSuccessMsg # (successMsg)
{
    my ($successMsg) = @_;

    my %backupStats;
    foreach my $line (@{$successMsg}) {
        chomp($line);
        given ($line) {
            when (/^ElapsedTime/) {
                ($backupStats{time}) = $line =~ m/ElapsedTime\s([0-9\.]+)\s/g;
            }
            when (/^SourceFiles/) {
                ($backupStats{nFiles}) = $line =~ m/SourceFiles\s([0-9]+)/g;
            }
            when (/^NewFiles/) {
                ($backupStats{nNew}) = $line =~ m/NewFiles\s([0-9]+)/g;
            }
            when (/^DeletedFiles/) {
                ($backupStats{nDeleted}) = $line =~ m/DeletedFiles\s([0-9]+)/g;
            }
            when (/^ChangedFiles/) {
                ($backupStats{nChanged}) = $line =~ m/ChangedFiles\s([0-9]+)/g;
            }
            when (/^TotalDestinationSizeChange/) {
                ($backupStats{size}) = $line =~ m/TotalDestinationSizeChange\s([0-9]+)/g;
            }
            when (/^Errors/) {
                ($backupStats{nErrors}) = $line =~ m/Errors\s([0-9]+)/g;
            }
        }
    }
    return \%backupStats;
}

sub _bytesToMB
{
    my ($bytes) = @_;
    return sprintf('%.3f', $bytes/1048576);
}

sub _existsFullBackup
{
    my ($ebackup, $type) = @_;

    my $status;
    try {
        $status = $ebackup->remoteStatus();
    } catch ($e) {
        my $errorMsg = _errorMessageFromDuplicityCmd($e);

        _prepareAndSendEvent(0, $type, $errorMsg);
        die $errorMsg;
    }

    foreach my $st (@{ $status }) {
        if ($st->{type} eq 'Full') {
            return 1;
        }
    }

    return 0;
}

sub usage
{
    print __('Usage:');
    print "\n";
    print "$0 [--full|--incremental|--full-only-once]";
    print "\n";
}

sub _adjustType
{
    my ($ebackup, $type) = @_;

    # We need an updated cache to avoid errors while checking the status if backup
    # files have been removed
    #
    # We can disable this feature to speed up process when there is not full backup,
    # incremental is specified and status cache is not properly updated it will give a
    # "old signatures not found" error
    try {
        $ebackup->remoteGenerateStatusCache();
    } catch ($e) {
        my $errorMsg = _errorMessageFromDuplicityCmd($e);
        _prepareAndSendEvent(0, $type, $errorMsg);
        die $errorMsg;
    }

    if ($type eq 'full-only-once') {
        if (_existsFullBackup($ebackup, $type)) {
            $type = 'incremental';
        } else {
            EBox::info('No full backup detected in archive. We will make a full backup, following backups will be incremental');
            $type = 'full';
        }
    } elsif ($type eq 'incremental') {
        if (not _existsFullBackup($ebackup, $type)) {
            EBox::warn('Incremental backup requested but there are not full backups. Switching to full backup mode');
            $type = 'full';
        }
    }

    return $type;
}



sub _removeExpiredBackups
{
    my ($ebackup) = @_;

    try {
        my $removeArguments = $ebackup->remoteDelOldArguments();
        EBox::Sudo::root($removeArguments);
        # Delete orphaned metadata
        my $settingsModel = $ebackup->model('BackupSettings');
        my $method = $settingsModel->row()->valueByName('method');
    } catch (EBox::Exceptions::Sudo::Command $e) {
        my $errorMsg = $e->error()->[-1];
        chomp($errorMsg) if $errorMsg;
        $errorMsg = _mangleDuplicityErrorMsg($errorMsg);
        if ( $errorMsg eq 'BackendException' ) {
            $errorMsg = __('Cannot contact to the backup server. Check your '
                           . 'Internet connection prior to perform the backup');
        }
        throw EBox::Exceptions::External($errorMsg);
    }
}

sub _cleanup
{
    my ($ebackup) = @_;

    my $duplicityWrapper = $ebackup->DUPLICITY_WRAPPER();
    my $remoteUrl = $ebackup->_remoteUrl();
    my $cmd = "$duplicityWrapper cleanup --force $remoteUrl";
    EBox::debug('Trying to clean up incomplete backups');
    try {
        EBox::Sudo::root($cmd);
    } catch ($e) {
        my $error = _errorMessageFromDuplicityCmd($e);
        EBox::error("When trying to clean up old backup sets: $error");
    }
}

sub _preCheckTargetHook
{
    my ($ebackup, $type) = @_;
    try {
        $ebackup->preCheckTargetHook();
    } catch ($e) {
        EBox::error("Error in pre-check target hook: $e");
        _prepareAndSendEvent(0, $type, "$e");
        $ebackup->backupProcessUnlock();
        exit 1;
    }
}

# Try to back up with n_tries using a geometric progression
# Returns two values in an array:
#      success  - Boolean indicating if it was a success
#      errorMsg - String if there wasn't success, the error message
#
sub _performBackup
{
    my ($ebackup, $backupCmd) = @_;

    my $ebackupConfKeys = EBox::Config::configKeysFromFile(EBACKUP_CONF);
    my $nTries   = $ebackupConfKeys->{n_tries};
    if ((not defined $nTries) or $nTries < 1) {
        $nTries = 1;
    }
    my $initial  = $ebackupConfKeys->{initial_value};
    if ((not defined $initial) or $initial < 1) {
        $initial = 1;
    }
    my $factor   = $ebackupConfKeys->{scale_factor};
    if ((not defined $factor) or $factor < 1) {
        $factor = 1;
    }

    my ($success, $successMsg, $errorMsg) = (0, '', '');

    my $preHookError;
    try {
        $ebackup->preBackupHook();
    } catch (EBox::Exceptions::Sudo::Command $e) {
        my @error = @{ $e->error() };
        $preHookError = "@error";
    } catch ($e) {
        $preHookError = "$e";
    }
    if ($preHookError) {
        $success = 0;
        $errorMsg = __x('Error running pre backup hook {err}',
                        err => $preHookError);
        return ($success, $successMsg, $errorMsg);
    }

    foreach my $try ( 1 .. $nTries ) {
        try {
            EBox::info("Backing up files to destination: try $try");
            $successMsg = EBox::Sudo::root($backupCmd);
            $success = 1;
        } catch ( EBox::Exceptions::Sudo::Command $e) {
            $success = 0;
            my @error = @{ $e->error() };
            chomp $error[-1] if @error;
            $errorMsg = "@error";
            $errorMsg = _mangleDuplicityErrorMsg($errorMsg);
        } catch ($e) {
            $success = 0;
            $errorMsg = "$e";
        }
        last if ($success);
        # Error comes from a timeout? We can only assure the exception
        # comes from the backend
        last unless ( $errorMsg eq 'BackendException' );
        if ( $try < $nTries ) {
            my $timeout = int($initial * $factor**($try - 1));
            EBox::warn("Cannot contact the server: sleeping for $timeout seconds");
            sleep($timeout);
        } else {
            # Set a beautiful message to send to the user
            $errorMsg = __('Cannot contact to the backup server. Check your '
                           . 'Internet connection prior to perform the backup');
        }
    }

    my $postHookError;
    try {
        $ebackup->postBackupHook();
    } catch (EBox::Exceptions::Sudo::Command $e) {
        my @error = @{ $e->error() };
        $postHookError = "@error";
    } catch ($e) {
        $postHookError = "$e";
    }

    if ($postHookError) {
        $success = 0;
        $errorMsg = __x('Error running post backup hook {err}',
                        err => $postHookError);
    }

    return ($success, $successMsg, $errorMsg);
}

sub _errorMessageFromDuplicityCmd
{
    my ($ex) = @_;
    if ($ex->isa('EBox::Exceptions::Sudo::Command')) {
        my @error = @{  $ex->error() };
        chomp $error[-1] if @error;
        my $errorMsg = "@error";
        return _mangleDuplicityErrorMsg($errorMsg);
    }  else {
        return "$ex";
    }
}

sub backupCmd
{
    my ($ebackup, $type) = @_;
    my $backupCmd = $ebackup->remoteArguments($type, {});
    return $backupCmd;
}

my $type = 'full';
if ($ARGV[0]) {
    if ($ARGV[0] eq '--incremental') {
        $type = 'incremental';
    } elsif ($ARGV[0] eq '--full') {
        $type = 'full';
    } elsif ($ARGV[0] eq '--full-only-once') {
        $type = 'full-only-once';
    } else {
        print "Invalid argument: " . $ARGV[0];
        print "\n";
        usage();
        exit 1;
    }
}

EBox::init();

my $globalRO = 1;
my $ebackup = EBox::Global->getInstance($globalRO)->modInstance('ebackup');
unless ($ebackup->isEnabled() ) {
    print "Backup module is disabled\n";
    exit 0;
}
unless ($ebackup->configurationIsComplete()) {
    print "Backup module configuration is not completed. Configure it and retry\n";
    exit 0;
}

my $mustExit = 0;

try {
    $ebackup->backupProcessLock()
} catch {
    my $alreadyMsg = __('Another backup process is running, wait until it finishes and try again');
    print $alreadyMsg;
    print "\n";
    _prepareAndSendEvent(0, $type, $alreadyMsg);
    $mustExit= 1;
}
if ($mustExit) {
    exit $mustExit;
}

my ($success, $successMsg, $errorMsg) = (1, '', '');

try {
    $type = _adjustType($ebackup, $type);

    _preCheckTargetHook($ebackup, $type);

    try {
        $ebackup->checkTargetStatus($type);
    } catch ($e) {
        EBox::error("Error in backup target: $e");
        _prepareAndSendEvent(0, $type, "$e");
        $ebackup->backupProcessUnlock();
        exit 1;
    }

    if ($type eq 'full') {
        try {
            _removeExpiredBackups($ebackup);
        } catch ($e) {
            EBox::error("Error trying to remove old backups: $e");
        }
    }

    try {
        $ebackup->dumpExtraData($globalRO);
    } catch ($e) {
        EBox::error("Error dumping server metadata: $e. Backup process continues but you could not be able to restore the server configuration or other extra data with this backup");
    }

    my $backupCmd = backupCmd($ebackup, $type);
    ($success, $successMsg, $errorMsg) = _performBackup($ebackup, $backupCmd);

    if ($success) {
        EBox::info('Backup process finished successfuly');
    } else {
        my $msg = "Backup failed: $errorMsg";
        print "$msg\n";
        EBox::error($msg);
        _cleanup($ebackup);
    }

    _prepareAndSendEvent($success, $type, $errorMsg);

} catch ($e) {
    _finally();
    $e->throw();
}
_finally();

sub _finally
{
    try {
        if ($success) {
            $ebackup->waitForUpdateStatusInBackground();

            $ebackup->remoteGenerateStatusCache();

            # Create file list
            $ebackup->remoteGenerateListFile();
        }
    } catch ($e) {
        $ebackup->backupProcessUnlock();
        $e->throw();
    }
    $ebackup->backupProcessUnlock();
}

# Parse the successMsg
my $backupStats = undef;
if ($success) {
    $backupStats = _parseSuccessMsg($successMsg);
}

# update logs, we do this even with a failed backup bz it could change disk
# usage, with leftover files..
$ebackup->gatherReportInfo($type, $backupStats);

exit 0;
